iT邦幫忙

2025 iThome 鐵人賽

DAY 24
0

前言

  • 今天是中秋連假第一天,依然要繼續修改未完成的頁面。 今天繼續將 prompt.html 頁面樣式,並且整合先前 index.html 的 Navigation Bar,使風格一致。

prompt.html 頁面設計

<!doctype html>
<html lang="zh-Hant">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Nova Reel – 文字轉影片 Demo</title>
    <link rel="stylesheet" href="style.css">
    <style>
      :root { --bg:#0b0f14; --card:#0f1620; --text:#e6edf3; --muted:#9fb0c3; --primary:#3aa0ff; --danger:#ff5470; --ok:#22c55e; }
      html,body{margin:0;height:100%;background:var(--bg);color:var(--text);font:16px/1.5 ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,"Noto Sans",sans-serif;}
      .wrap{max-width:980px;margin:40px auto;padding:24px;}
      .card{background:var(--card);border:1px solid #1d2733;border-radius:16px;padding:20px;box-shadow:0 10px 30px rgba(0,0,0,.25)}
      h1{margin:0 0 16px;font-size:22px}
      p.muted{color:var(--muted);margin-top:8px}
      label{display:block;margin:12px 0 6px}
      textarea,select,input[type="number"],input[type="text"]{width:100%;background:#0c121a;color:var(--text);border:1px solid #203040;border-radius:12px;padding:12px}
      .row{display:grid;grid-template-columns:1fr 1fr;gap:12px}
      .btns{display:flex;gap:12px;margin-top:16px}
      button{appearance:none;border:0;padding:12px 16px;border-radius:12px;background:var(--primary);color:white;font-weight:600;cursor:pointer}
      button.secondary{background:#223347}
      button:disabled{opacity:.6;cursor:not-allowed}
      .status{margin-top:16px;padding:12px;border-radius:12px;background:#0c121a;border:1px dashed #203040}
      progress{width:100%;height:12px}
      video{width:100%;max-height:480px;border-radius:12px;background:#000;margin-top:12px}
      .kvs{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin-top:8px}
      .kv{font-size:13px;color:var(--muted)}
      a.link{color:#9fd1ff}
      .footer{margin-top:16px;color:var(--muted);font-size:13px}
      @media (max-width:720px){.row{grid-template-columns:1fr}}
    </style>
  </head>
  <body>
    <div class="navbar">
        <div class="nav-left">
            <h2 class="logo">🎬 影片平台</h2>
            <a href="index.html" class="nav-link">我的影片</a>
            <a href="prompt.html" class="nav-link active">生成影片</a>
        </div>
        <div class="nav-actions">
            <span id="welcomeUser"></span>
            <button id="logoutBtn">登出</button>
        </div>
    </div>
    <div class="wrap">
      <div class="card">
        <h1>Amazon Nova Reel – 文字轉影片</h1>
        <p class="muted">在下方輸入你的提示語(prompt),點「開始生成」。此頁會呼叫你在 API Gateway/Lambda 的 <code>POST /reels</code> 與 <code>GET /reels/{job_arn}</code>。
          設定 <code>prompt.js</code> 內的 <code>API_BASE</code> 後即可部署到 S3/CloudFront。</p>

        <label for="prompt">提示語(Prompt)</label>
        <textarea id="prompt" rows="5" placeholder="例:Golden hour drone shot of Taipei skyline, gentle camera dolly-in, cinematic lighting"></textarea>

        <div class="row">
          <div>
            <label for="duration">時長(秒,6 的倍數)</label>
            <select id="duration">
              <option value="6">6</option>
              <option value="12">12</option>
              <option value="18">18</option>
              <option value="24">24</option>
            </select>
          </div>
          <div>
            <label for="dimension">解析度</label>
            <select id="dimension">
              <option value="1280x720" selected>1280x720(720p)</option>
            </select>
          </div>
        </div>

        <div class="row">
          <div>
            <label for="region">Bedrock 區域</label>
            <select id="region">
              <option value="ap-northeast-1" selected>ap-northeast-1(Tokyo)</option>
              <option value="us-east-1">us-east-1(N. Virginia)</option>
            </select>
          </div>
          <div>
            <label for="seed">隨機種子(可空白)</label>
            <input id="seed" type="number" placeholder="不填則由後端隨機" />
          </div>
        </div>

        <div class="btns">
          <button id="btnStart">開始生成</button>
          <button id="btnCancel" class="secondary" disabled>取消輪詢</button>
        </div>

        <div class="status" id="statusBox" hidden>
          <div id="statusText">等待中…</div>
          <progress id="progress" max="100" value="5"></progress>
          <div class="kvs">
            <div class="kv">Job ARN:<span id="jobArn">—</span></div>
            <div class="kv">狀態:<span id="jobState">—</span></div>
            <div class="kv">輸出:<span id="outLink">—</span></div>
            <div class="kv">錯誤:<span id="errMsg">—</span></div>
          </div>
          <video id="preview" controls playsinline></video>
        </div>

        <div class="footer">© vlog.nipapa.tw — Demo UI。請先在 <code>prompt.js</code> 設定 <code>API_BASE</code> 與 CORS。</div>
      </div>
    </div>
    <script src="/prompt.js" defer></script>
  </body>
</html>

prompt.js

/* =============================
 * Nova Reel Frontend – prompt.js
 * 必須登入才能使用
 * =========================== */

const API_BASE = "https://iwlw3i3ys4.execute-api.ap-northeast-1.amazonaws.com/prod"; // 你的 API Gateway base URL

// 🔑 檢查登入狀態
const token = localStorage.getItem("jwt");
if (!token) {
  window.location.href = "/login.html";
}

// 工具
const $ = (id) => document.getElementById(id);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

// UI 元件
const promptEl = $("prompt");
const durationEl = $("duration");
const dimensionEl = $("dimension");
const regionEl = $("region");
const seedEl = $("seed");
const statusBox = $("statusBox");
const statusText = $("statusText");
const progressBar = $("progress");
const jobArnEl = $("jobArn");
const jobStateEl = $("jobState");
const outLinkEl = $("outLink");
const errMsgEl = $("errMsg");
const previewEl = $("preview");
const btnStart = $("btnStart");
const btnCancel = $("btnCancel");

let cancelFlag = false;

// Busy 狀態
function setBusy(busy) {
  btnStart.disabled = busy;
  btnCancel.disabled = !busy;
}
function showStatus(show = true) { statusBox.hidden = !show; }
function setProgress(v) { progressBar.value = Math.min(100, Math.max(0, v)); }
function linkify(url) {
  if (!url) return "—";
  const a = document.createElement("a");
  a.href = url;
  a.className = "link";
  a.textContent = "下載/播放連結";
  a.target = "_blank";
  return a.outerHTML;
}

// 建片
async function startJob() {
  cancelFlag = false;
  setBusy(true);
  showStatus(true);
  setProgress(5);
  statusText.textContent = "建立工作中…";
  jobArnEl.textContent = "—";
  jobStateEl.textContent = "—";
  outLinkEl.innerHTML = "—";
  errMsgEl.textContent = "—";
  previewEl.removeAttribute("src");

  const body = {
    prompt: promptEl.value?.trim() || "A cinematic shot of a clear droplet falling into water bowl",
    duration_seconds: Number(durationEl.value),
    fps: 24,
    dimension: dimensionEl.value,
    region: regionEl.value,
  };
  if (seedEl.value) body.seed = Number(seedEl.value);

  try {
    const resp = await fetch(`${API_BASE}/reels`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "Authorization": "Bearer " + token
      },
      body: JSON.stringify(body),
    });

    if (resp.status === 401) {
      localStorage.removeItem("jwt");
      window.location.href = "/login.html";
      return;
    }

    if (!resp.ok) throw new Error(`Create failed: HTTP ${resp.status}`);

    const { job_arn, status } = await resp.json();
    jobArnEl.textContent = job_arn;
    jobStateEl.textContent = status || "InProgress";
    statusText.textContent = "已送出,開始輪詢進度…";

    await poll(job_arn);
  } catch (err) {
    console.error(err);
    statusText.textContent = "建立工作失敗";
    errMsgEl.textContent = String(err.message || err);
  } finally {
    setBusy(false);
  }
}

// 輪詢
async function poll(jobArn) {
  setProgress(10);
  let tries = 0;
  while (!cancelFlag && tries < 120) {
    tries++;
    try {
      const resp = await fetch(`${API_BASE}/reels/${encodeURIComponent(jobArn)}`, {
        headers: { "Authorization": "Bearer " + token }
      });

      if (resp.status === 401) {
        localStorage.removeItem("jwt");
        window.location.href = "/login.html";
        return;
      }

      if (!resp.ok) throw new Error(`Query failed: HTTP ${resp.status}`);
      const data = await resp.json();

      jobStateEl.textContent = data.status;
      statusText.textContent = `查詢中(第 ${tries} 次)…`;
      setProgress(Math.min(95, 10 + tries * (80 / 120)));

      if (data.status === "Completed") {
        statusText.textContent = "完成!";
        setProgress(100);
        if (data.presigned_url) {
          outLinkEl.innerHTML = linkify(data.presigned_url);
          previewEl.src = data.presigned_url;
          previewEl.play().catch(()=>{});
        } else if (data.s3_uri) {
          outLinkEl.textContent = data.s3_uri;
        }
        return;
      }

      if (data.status === "Failed") {
        setProgress(100);
        statusText.textContent = "失敗";
        errMsgEl.textContent = data.message || "Unknown";
        return;
      }

    } catch (err) {
      console.error(err);
      statusText.textContent = "輪詢錯誤,稍後重試…";
      errMsgEl.textContent = String(err.message || err);
    }

    await sleep(8000);
  }

  if (cancelFlag) {
    statusText.textContent = "已取消輪詢";
  } else {
    statusText.textContent = "超過最大輪詢次數,請稍後再查";
  }
}

btnStart.addEventListener("click", startJob);
btnCancel.addEventListener("click", () => { cancelFlag = true; setBusy(false); });

結論

  • 完成頁面轉接 API 設定了,也可以根據 username 將生成出的影片放置在對應的 S3 路徑。
  • 接下來要檢查的部分,是針對各段程式碼及 API 的提取內容路徑進行檢查,避免去偷撈別人資料的狀況發生。

上一篇
【Day 23】 實作 Amazon Nova Reel 的生成介面及 API (上)
系列文
無法成為片師也想拍 Vlog?!個人影音小工具的誕生!24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言